<?php
/**
 * APPL
 *
 * PHP version 5 (>= 5.3)
 *
 * @category DocumentObjectModel
 * @package  APPL_Dom
 * @author   Chad Gray <chadwickgray@gmail.com>
 * @version  SVN: $Id$
 */
/**
 * Extends the DOMDocument to implement custom methods.
 *
 * @category DocumentObjectModel
 * @package  APPL_Dom
 * @author   Chad Gray <chadwickgray@gmail.com>
 */
class APPL_DOMDocument extends DOMDocument
{
    /**
     * Constructs elements and texts from an array
     * The array can contain an element's name in the index part
     * and an element's text in the value part.
     *
     * It can also create a xml with the same element tagName on the same
     * level.
     *
     * Example:
     * <code>
     * $data = array (
     *     "nodes" => array (
     *         "node" => array (
     *             0 => "text",
     *             1 => array (
     *                 "field" => array (
     *                     0 => "hello",
     *                     1 => "world"
     *                 )
     *             )
     *         )
     *     )
     * );
     * $doc = new APPL_DOMDocument('1.0', 'utf-8');
     * $doc->loadArray($data);
     * echo $doc->saveXML().PHP_EOL;
     * </code>
     * Resulting XML
     * <pre>
     * <!-- NOTE: '&lt;' is used here instead of '<' to allow for proper phpdoc rendering -->
     * &lt;nodes>
     *   &lt;node>text&lt;node>
     *   &lt;node>
     *     &lt;field>hello&lt;field>
     *     &lt;field>world&lt;field>
     *   &lt;node>
     * &lt;nodes>
     * </pre>
     *
     *
     * @param mixed             $data       An array or string.
     * @param DOMElement        $domElement The element from where the array will be
     *                                       construct to.
     * @param array|Zend_Config $options     Configuration data, currently only
     *                                       option is 'use_cdata'
     *
     * @return void
     */
    public function loadArray($data, DOMElement $domElement = null, $options = array())
    {
        if ($options instanceof Zend_Config) {
            $options = $options->toArray();
        }
        // wrap text values in CDATA tags, default is true
        $useCdata   = isset($options['use_cdata']) ? $options['use_cdata'] : true;
        $domElement = is_null($domElement) ? $this : $domElement;
        if (is_array($data)) {
            foreach ($data as $index => $element) {
                if ($index[0] == '@') {
                    $domElement->setAttribute(substr($index, 1), $element);
                } else {
                    if (is_numeric($index)) {
                        if (intval($index) == 0) {
                            $node = $domElement;
                        } else {
                            $node = $this->createElement($domElement->tagName);
                            $domElement->parentNode->appendChild($node);
                        }
                    } else {
                        $node = $this->createElement($index);
                        $domElement->appendChild($node);
                    }
                    $this->loadArray($element, $node, $options);
                }
            }
        } else {
            $child = (trim($data) && $useCdata)
                   ? $this->createCDataSection($data)
                   : $this->createTextNode($data);
            $domElement->appendChild($child);
        }
    }
    /**
     * Factory method to create new APPL_DOMDocument
     *
     * Example:
     * <code>
     * // load xml file
     * $doc = APPL_DOMDocument::factory('/path/to/myfile.xml');
     * // load html file with 'html' hint
     * $doc = APPL_DOMDocument::factory('/path/to/myfile.html', 'html');
     * // load array with configuration data
     * $config = array(
     *    'use_cdata'=>false
     * );
     * $doc = APPL_DOMDocument::factory($data, $config);
     * </code>
     * @param mixed $data   the data to load, can be array, path to a html or xml file, or string
     *                       containing xml or html
     * @param mixed $options Optional Configuration data
     *
     * @return APPL_DOMDocument
     *
     * @throws APPL_Exception thrown if the specified html or xml file is unreadable
     *                       of if an invalid type hint is given
     *
     * @throws APPL_Exception_FileNotFound thrown when the specified html or xml file
     *                                    cannot be found
     *
     * @todo possibly allow for file which contains serialized array or json
     */
    public static function factory($data, $options = array())
    {
        // the document to return
        $doc = new self();
        // convert Zend_Config to an array to simplify use
        if (isset($options) && ($options instanceof Zend_Config)) {
            $options  = $options->toArray();
            $typeHint = @$options['type_hint'];
        }
        if (is_string($options)) {
            $typeHint = $options;
            $options = array();
        }
        if (is_array($data)) {
            $typeHint = 'array';
        } else {
            // if first character is not '<' the data is assumed to
            // be a filename and NOT xml or html
            if ($data[0] != '<') {
                // if data is a file, get the contents
                if (!is_file($data)) {
                    require_once 'APPL/Exception/FileNotFound.php';
                    throw new APPL_Exception_FileNotFound($data);
                }
                if (!is_readable($data)) {
                    require_once 'Zend/Exception.php';
                    throw new Zend_Exception("File '$data' is not readable");
                }
                // no hint was provided. see if we can get it from the file path
                if (!isset($typeHint)) {
                    $typeHint = pathinfo($data, PATHINFO_EXTENSION);
                }
                // get the contents of the file
                $data = file_get_contents($data);
            }
        }
        // default hint to xml
        if (!isset($typeHint)) {
            $typeHint = 'xml';
        }
        // based on the type of data, call the appropriate load method
        switch ($typeHint) {
        case "array":
            $doc->loadArray($data, null, $options);
            break;
        case "xml":
            $doc->loadXml($data);
            break;
        case "html":
            $doc->loadHtml($data);
            break;
        default:
            require_once 'APPL/Exception.php';
            throw new APPL_Exception("Unknown content type '$typeHint'");
        }
        if (isset($options['stylesheet'])) {
            $doc->applyStyleSheet($options['stylesheet']);
        }
        return $doc;
    }
    public function applyStyleSheet($styleSheet)
    {
        if (!is_file($styleSheet)) {
            require_once 'APPL/Exception/FileNotFound.php';
            throw new APPL_Exception_FileNotFound("File '{$styleSheet}' was not found");
        }
        $xsl = new DOMDocument();
        $xsl->load($styleSheet);
        $processor   = new XSLTProcessor();
        $processor->importStyleSheet($xsl);
        $transformed = $processor->transformToDoc($this);
        // remove all nodes in this document
        $this->removeChild($this->documentElement);
        $this->appendChild($this->importNode($transformed->documentElement, 1));
        return $this;
    }
    /**
     * Sets public property formatOutput to true
     * For convenience, this method returns the calling object
     *
     * @return APPL_DOMDocument
     */
    public function formatOutput()
    {
        $this->formatOutput = true;
        return $this;
    }
    /**
     * Converts a given DOMNode to an array or string, if no DOMNode is supplied
     * the method will use $this
     *
     * Example:
     * <code>
     * $sXml = <<<XML
     * <nodes>
     *   <node>text<node>
     *   <node>
     *     <field>hello<field>
     *     <field>world<field>
     *   <node>
     * <nodes>
     * XML;
     * $doc = APPL_DOMDocument::factory($sXml);
     * var_dump($doc->toArray());
     * </code>
     * Output:
     * <pre>
     * array (
     *     "nodes" => array (
     *         "node" => array (
     *             0 => "text",
     *             1 => array (
     *                 "field" => array (
     *                     0 => "hello",
     *                     1 => "world"
     *                 )
     *             )
     *         )
     *     )
     * );
     * </pre>
     *
     *
     * @param DOMNode $domNode the node to transform
     *
     * @return array
     */
    public function toArray(DOMNode $domNode = null)
    {
        // return empty array if dom is blank
        if (is_null($domNode) && !$this->hasChildNodes()) {
            return array();
        }
        $domNode = (is_null($domNode)) ? $this->documentElement : $domNode;
        if (!$domNode->hasChildNodes()) {
            $result = $domNode->nodeValue;
        } else {
            $result = array();
            foreach ($domNode->childNodes as $childNode) {
                // how many of these child nodes do we have?
                // this will give us a clue as to what the result structure should be
                $childNodeList = $domNode->getElementsByTagName($childNode->nodeName);
                $childCount = 0;
                // there are x number of childs in this node that have the same tag name
                // however, we are only interested in the # of siblings with the same tag name
                foreach ($childNodeList as $node) {
                    if ($node->parentNode->isSameNode($childNode->parentNode)) {
                        $childCount++;
                    }
                }
                if (($childNode->nodeName[0] != '#')
                        || ($childNode->nodeName[0] == '#' && trim($childNode->nodeValue))) {
                    $value = $this->toArray($childNode);
                    $key   = ($childNode->nodeName[0] == '#') ? 0 : $childNode->nodeName;
                    // DOMDocument interprets new lines as empty text nodes, avoid these
                    $value = is_array($value) ? $value[$childNode->nodeName] : $value;
                    // how many of thse child nodes do we have?
                    if ($childCount > 1) {  // more than 1 child - make numeric array
                        $result[$key][] = $value;
                    } else {
                        $result[$key] = $value;
                    }
                }
            }
            // if the child is <foo>bar</foo>, the result will be array(bar)
            // make the result just 'bar'
            if (count($result) == 1 && isset($result[0]) && !is_array($result[0])) {
                $result = $result[0];
            }
        }
        $attributes = $this->_getAttributesArray($domNode);
        // if we have attributes, merge them into the array
        if (count($attributes)) {
            if (!is_array($result)) {
                $result = (trim($result)) ? array($result) : array();
            }
            $result = array_merge($result, $attributes);
        }
        return array($domNode->nodeName=>$result);
    }
    /**
     * Returns an array containing all attributes within the given DOMNode $node
     *
     * @param DOMNode $domNode
     *
     * @return array
     */
    protected function _getAttributesArray(DOMNode $domNode)
    {
        // get our attributes if we have any
        $attributes = array();
        if ($domNode->hasAttributes()) {
            foreach ($domNode->attributes as $attributeNode) {
                // retain namespace prefixes
                $attributes["@{$attributeNode->nodeName}"] = $attributeNode->nodeValue;
            }
        }
        // check for namespace attributes 
        // Namespaces will not show up in the attributes list
        if ($domNode instanceof DOMElement) {
            $xpath = new DOMXPath($this);
            foreach ($xpath->query('namespace::*', $domNode) as $node) {
                if ($domNode->hasAttribute($node->nodeName)) {
                    $attributes['@'.$node->nodeName] = $node->nodeValue;
                }
            }
        }
        return $attributes;
    }
}
